Skip to content

feat(auth): multi-session multi-profile support (client + SSR) - #14875

Open
bobbor wants to merge 5 commits into
mainfrom
auth/feat/multi-session-support
Open

feat(auth): multi-session multi-profile support (client + SSR)#14875
bobbor wants to merge 5 commits into
mainfrom
auth/feat/multi-session-support

Conversation

@bobbor

@bobbor bobbor commented Jul 13, 2026

Copy link
Copy Markdown
Member

Description

Adds multi-session / multi-profile support to Cognito auth: multiple users can be signed in to the same user pool simultaneously, with one active session at a time and the others parked. Works both client-side and in SSR (HttpOnly cookies).

New public APIs

  • setCurrentUser(username) — switch the active session to an already-signed-in user (throws if not signed in). Client + server.
  • listCurrentUsers(): Promise<AuthUser[]> — list all signed-in users, active first. Client + server.

Server variants live under aws-amplify/auth/server and accept a contextSpec, operating on the per-request cookie-backed token store.

Storage model

  • New AuthUserList key holds a comma-separated, ordered roster (active first), kept alongside the existing LastAuthUser (which mirrors AuthUserList[0] for cross-SDK compatibility). Per-user token namespaces are unchanged.

Hub events (boundary model)

  • New: userSignedIn, switchActiveUser, userSignedOut (per-session roster membership / active-pointer moves).
  • signedIn/signedOut now fire only at the empty↔non-empty roster edges; payloads unchanged ({ username, userId }), with an optional user payload added to signedOut/tokenRefresh. Backward compatible for existing single-session apps.

Server-side safety

Server exposure is deliberately minimal and non-destructive: the per-request token provider surfaces only a narrow AuthSessionSwitcher (read + validated reorder). Destructive token-store operations (storeTokens, clearTokens, clearTokensForUser, removeSession) never cross the server context boundary. Reachability is via a new additive AuthClass.getTokenProvider() accessor; core's generic TokenProvider interface is unchanged.

Commits

  1. feat(core): add multi-session auth Hub events
  2. feat(auth): add client-side multi-session support
  3. feat(auth): expose multi-session APIs for server-side rendering

Testing

  • yarn build clean across @aws-amplify/auth, aws-amplify, @aws-amplify/adapter-nextjs.
  • @aws-amplify/auth: full unit suite green (1193+ tests), incl. new tests for the roster, boundary events, setCurrentUser/listCurrentUsers (client + server), and the session switcher.
  • aws-amplify: 51/51 incl. the API-surface exports guard (updated for the intended new symbols only).
  • yarn lint clean.
  • Underlying modules mocked (not Amplify.getConfig), per repo convention.

Notes

  • adapter-nextjs required no change — the server APIs are callable through runWithAmplifyServerContext like getCurrentUser.
  • Concurrent active sessions are out of scope (one active user at a time).

Checklist

  • Tests added/updated
  • Changeset added
  • Build + lint pass locally

bobbor added 4 commits July 13, 2026 12:46
Add userSignedIn, switchActiveUser, and userSignedOut events to
AuthHubEventData, and add an optional user payload to signedOut and
tokenRefresh. Supports the multi-session boundary event model.
Introduce an AuthUserList session roster (active user first) alongside
LastAuthUser, and add setCurrentUser and listCurrentUsers. Sign-in/out
now emit boundary Hub events (userSignedIn/switchActiveUser/
userSignedOut; signedIn/signedOut only at roster empty<->non-empty
edges). Adds per-user token clearing, credential-cache busting on
switch, and the createAuthSessionSwitcher primitive.
Add server variants of setCurrentUser and listCurrentUsers that accept
a contextSpec and operate on the per-request (cookie-backed) token
store. Reachability is via a minimal, non-destructive AuthSessionSwitcher
(read + validated reorder only) surfaced by createUserPoolsTokenProvider
and reached through a new additive AuthClass.getTokenProvider accessor.
No destructive token operation crosses the server context boundary.
@bobbor
bobbor requested review from a team, avi-karthik, pranavosu and sarayev as code owners July 13, 2026 13:03
@changeset-bot

changeset-bot Bot commented Jul 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2c52b05

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 7 packages
Name Type
@aws-amplify/auth Minor
@aws-amplify/core Minor
aws-amplify Minor
@aws-amplify/pubsub Patch
@aws-amplify/api-graphql Patch
@aws-amplify/api Patch
@aws-amplify/datastore Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@osama-rizk osama-rizk added the run-tests run the pr-label workflow label Jul 15, 2026
const resolvedUsers = await Promise.all(
roster.map(async rosterUsername => {
try {
const idTokenKey = `${AUTH_KEY_PREFIX}.${userPoolClientId}.${rosterUsername}.idToken`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] This constructs the idToken storage key by hand (${AUTH_KEY_PREFIX}.${userPoolClientId}.${rosterUsername}.idToken) instead of delegating to authTokenStore.getStoredIdToken(rosterUsername), which already encapsulates exactly this key logic via getAuthKeys. The server path in apis/server/listCurrentUsers.ts correctly uses switcher.getStoredIdToken(). If the key schema ever changes, this client path will silently diverge.

Replace the manual key construction + raw getItem + decodeJWT block with:

const idToken = await authTokenStore.getStoredIdToken(rosterUsername);
if (!idToken) return undefined;
const { 'cognito:username': cognitoUsername, sub } = idToken.payload ?? {};

This also removes the need for the inner try/catch and aligns the two paths perfectly.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch 👍 — this was exactly the drift the server path was built to avoid. Switched to authTokenStore.getStoredIdToken(), dropped the manual key + decodeJWT + inner try/catch. Fixed in 2c52b05.

await clearCredentials();

// Resolve the now-active user for the event payload.
const currentUser = await getCurrentUser(Amplify);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] getCurrentUser(Amplify) goes through TokenOrchestrator.getTokens(), which can trigger a token refresh if the newly-active user's access token is expired. That's a surprising side-effect for what should be a cheap pointer move. dispatchSignOutBoundaryEvents handles the identical identity-resolution problem correctly by using getStoredIdToken() — this should do the same:

const idToken = await tokenStore.getStoredIdToken(username);
const userId = (idToken?.payload?.sub as string) ?? '';
Hub.dispatch('auth', { event: 'switchActiveUser', data: { username, userId } }, 'Auth', AMPLIFY_SYMBOL);

This also removes the getCurrentUser import dependency from this file.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, the refresh side-effect was surprising. Now resolves from stored tokens via getStoredIdToken() (mirroring dispatchSignOutBoundaryEvents), and skips the dispatch entirely if the identity can't be resolved — no more getCurrentUser import. Fixed in 2c52b05.

Comment thread packages/auth/src/providers/cognito/apis/server/listCurrentUsers.ts
this.getAuthUserListKey(),
list.join(','),
);
await this.getKeyValueStorage().setItem(this.getLastAuthUserKey(), list[0]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] AuthUserList and LastAuthUser are written sequentially here. A crash between the two leaves them out of sync (AuthUserList = bob,alice but LastAuthUser = alice from the previous write). The delete path has the right ordering comment, but the write path has the same race in the other direction. Since getAuthUserList() already treats AuthUserList as authoritative when present, worth adding an explicit comment that LastAuthUser here is best-effort / compatibility-only and doesn't affect roster correctness if the write is lost — otherwise the two-write sequence looks like an unguarded bug.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the clarifying comment — AuthUserList is authoritative (getAuthUserList prefers it), LastAuthUser is best-effort compat only, so a lost second write doesn't affect roster correctness. Fixed in 2c52b05.

);
if (legacyLastAuthUser && legacyLastAuthUser !== 'username') {
const migratedList = [legacyLastAuthUser];
await this.persistAuthUserList(migratedList);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] getAuthUserList is a read path, but on first invocation after upgrade it calls persistAuthUserList — a write. So listCurrentUsers (read-only by contract) silently mutates storage on its first call. In SSR with ephemeral or read-only storage this write will throw and break the read. Worth wrapping the migration write in a try/catch so that a storage failure during migration degrades gracefully rather than preventing the list from being returned.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice edge case 👍 — wrapped the migration persist in try/catch; on read-only storage the read still returns the migrated list, persistence just retries next time. Test added. Fixed in 2c52b05.

// drive a refresh) must not be invoked.
expect(loadTokensSpy).not.toHaveBeenCalled();
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] No test covers the signInDetails branch (the stored signInDetails key being present and populated on the returned AuthUser). Worth adding one case — the path is a distinct storage read that can fail independently of the idToken read.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added — one test with stored signInDetails populated on the returned AuthUser, one where the read fails and the user is still returned without it. Fixed in 2c52b05.

- listCurrentUsers (client): resolve idToken via getStoredIdToken
  instead of hand-building the storage key
- setCurrentUser: resolve switchActiveUser payload from stored tokens
  (no refresh side-effect); skip dispatch when identity unresolvable
- dispatchSignOutHubEvents: skip switchActiveUser instead of emitting
  an empty userId
- TokenStore: make legacy-roster migration write fail-safe on
  read-only storage; document LastAuthUser as best-effort compat
- server listCurrentUsers: document missing signInDetails in JSDoc
- tests for the signInDetails branch and updated paths
@bobbor

bobbor commented Jul 15, 2026

Copy link
Copy Markdown
Member Author

@soberm thanks for the thorough review! All 7 comments addressed in 2c52b05 — the major (hand-built storage key in client listCurrentUsers) now goes through getStoredIdToken() like the server path, plus the refresh side-effect in setCurrentUser, the empty-userId dispatch, the migration write-on-read, and the doc/test items. Full auth suite green locally (107 suites / 1199 tests). Ready for another look 👀

@osama-rizk osama-rizk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we please add e2e tests ?

Also, I ran the existing e2e tests — some failed, though a few look flaky. Can you please check them?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-tests run the pr-label workflow

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants